Razprsena tabela je zelo koristna podatkovna struktura, ki pride prav med
drugim pri nalogi 386, s katero se ubadamo te dni, pa se marsikje drugje.

[V tem e-mailu sicer ne morem risati skic ali podobnih stvari -- nekaj
primernih slicic je npr. na temle naslovu:
http://ciips.ee.uwa.edu.au/~morris/Year2/PLDS210/hash_tables.html]

Vsi poznamo navadne tabele ali polja (arrays), v katerih lahko hranimo neko
zaporedje elementov, do posameznega elementa pa pridemo tako, da povemo
njegov indeks.  Indeksi so obicajno zaporedna cela stevila od 0 naprej
(nekateri jeziki, npr. pascal, omogocajo tudi, da se indeksi zacnejo pri
kaksni drugi vrednosti, pa tudi to, da za indekse uporabimo kaj drugega kot
cela stevila (npr. znake, nastevne tipe ipd.), vendar vse to pomeni le, da
prevajalnik programerju za hrbtom dela uslugo in preracunava te indekse v
stevila od 0 naprej).

Lepo pri tabeli je, da dostop do posameznega elementa (branje ali pisanje)
obicajno vedno traja priblizno enako dolgo, ne glede na to, kateri indeks
nas zanima (ce zanemarimo vpliv predpomnilnika in takih reci).

Vcasih pa bi si zeleli za identifikacijo posameznih elementov uporabiti
kaksne druge reci kot zaporedna cela stevila od 0 naprej.  Tem podatkom, ki
enolicno identificirajo posamezen element, pravimo "kljuc".  Ce bi imeli
podatke o ljudeh, bi bil kljuc mogoce lahko EMSO.  Ocitno je, da EMSOv ne
moremo uporabiti kot indeksov v navadno tabelo, saj so to 13-mestna stevila,
tabele z 10^13 elementi pa si ne moremo privosciti (in tudi ce bi si jo, bi
bila strasansko neucinkovita, saj v tej drzavi ni 10^13 ljudi in vecina
EMSOv sploh ni v rabi).  Kljuc bi bil lahko vcasih tudi niz ali pa par
stevil ali tudi kaksna druga strukturirana vrednost.  Skratka, kako bi
organizirali stvari tako, da bi bi lahko za dostop do elementov uporabljali
poljubne take kljuce, cas dostopa pa bi bil se vedno priblizno konstanten
oz. enak za vse elemente?

Osnovna zamisel je, da uporabimo kar navadno tabelo (recimo ji T) z recimo N
elementi.  Indeksi v njej gredo potemtakem od 0 do N-1.  Kljuce, po katerih
prepoznavamo elemente, pa zdaj preslikajmo v indekse; recimo, da to opravi
neka funkcija h.  Element s kljucem k bi torej hranili v tabeli na indeksu
h(k).

Tezava, ki tu nastopi, je ta, da je lahko vec kljucev preslika v isti
indeks.  To je lahko posledica nerodno zasnovane funkcije h, pogosto pa je
to sploh neizogibno, ker je kljucev vec kot indeksov (primer: ce so kljuci
neki nizi, jih je lahko prakticno neomejeno mnogo in se bo gotovo kdaj
zgodilo, da se bosta dva kljuca preslikala v isti indeks).

Kaj torej storiti v primeru, ko bi radi hranili v tabeli dva kljuca, ki se
nam oba preslikata v isti indeks?  Moznih je vec resitev, meni se zdi se
najbolj uporabna naslednja (na zgoraj omenjeni strani ji pravijo
"chaining"): v tabeli imejmo namesto elementov samih raje verige elementov.
Vsaka celica tabele torej hrani kazalec na verigo (ali seznam, ce hocete --
linked list po domace) elementov, katerih kljuci so se preslikali v indeks
te celice.  Primer: v T[i] je kazalec na seznam elementov, za cigar kljuce
velja pogoj h(k) = i.

Seveda s tem, ko tvorimo sezname elementov, nimamo vec zagotovila, da bo cas
dostopa do vseh elementov enak -- ocitno potrebujemo vec casa, da pridemo do
elementov na koncu seznamov.  Zato si moramo prizadevati, da seznami ne bi
bili predolgi; torej mora biti indeksov dovolj (lepo je, ce jih je vsaj
toliko, kolikor kljucev mislimo mi res hraniti v nasi tabeli), h pa naj
kljuce cim bolj enakomerno razmece med vse indekse, da se nam ne bi slucajno
vsi preslikali na en kup in bi tako dobili en patolosko dolg seznam.

Opisani strukturi pravimo "razprsena tabela" ali tudi "zgoscena tabela" (po
domace: "hash tabela").  Ta dva izraza sicer zvenita, kot da pomenita ravno
nasprotno, ampak v resnici sta oba po svoje primerna.  Zelimo si, da se
kljuci ne pri preslikovali na iste indekse, zato naj bi h kljuce lepo
razprsila med vse indekse.  Po drugi strani je kljucev obicajno veliko vec
kot indeksov in zato h zgosti mnozico kljucev v mnozico indeksov.

Opisimo zdaj bolj eksplicitno algoritme za vpisovanje, branje in brisanje
elementa iz tabele (pomagal si bom z nekaksno pascaloidno psevdokodo --
upam, da vam bo dovolj razumljiva; moje znanje pascala je ze malo zarjavelo,
psevdokoda pa je koristna tudi zato, ker nam omogoca zanemariti podrobnosti,
ki ta hip niso tako pomembne):

type
  Kljuc = ...;
  Vrednost = ...;
  Zapis = record
    K: Kljuc;
    V: Vrednost;
    Naslednji: ^Zapis;
  end;
const N = ...;
var
  T: array[0..N-1] of ^Zapis;

InicializacijaHashTabele:
  Vsaka celica tabele T bo hranila nek seznam elementov.
  Zaenkrat so vsi ti seznami prazni, zato v vsako celico
  tabele T vpisimo kazalec nil (ali NULL ali karkoli ze pac).

DodajElement(NoviKljuc: Kljuc; NovaVrednost: Vrednost):
  1. Izracunajmo indeks, kamor bo treba vpisati ta kljuc:
     i := h(NoviKljuc);
  2. Pojdimo po seznamu, ki se pricne pri T[i].
  3. Ce v tem seznamu ze najdemo kljuc NoviKljuc, je torej
     ta kljuc ze v hash tabeli.  Kaj naredimo v takem
     primeru, je pac odvisno od tega, kako mislimo naso
     hash tabelo uporabljati.  Mogoce je to znak, da je v
     nasem programu neka napaka; mogoce moramo le
     vpisati vrednost NovaVrednost cez obstojeco vrednost
     pri tem kljucu; mogoce pa moramo staro in novo
     vrednost nekako skombinirati (npr. ce hocemo steti,
     kolikokrat smo nek kljuc dodali v hash tabelo).
  4. Ce pa v tem seznamu kljuca ne najdemo, ustvarimo
     nov zapis zanj in ga dodajmo v seznam -- najlazje bo
     najbrz kar na zacetek:
       var P: ^Zapis;
       New(P); P^.K := NoviKljuc; P^.V := NovaVrednost;
       P^.Naslednji := T[i]; T[i] := P;

BeriElement(K: Kljuc; var V: Vrednost);
  1. Ce je kljuc K sploh prisoten v nasi hash tabeli,
     mora biti pod naslednjim indeksom:
       i := h(K);
  2. Pojdimo po seznamu, ki se pricne pri T[i].
  3. Ce v njem najdemo kljuc K, vpisimo v
     V njegovo pripadajoco vrednost in koncajmo.
  4. Ce kljuca ne najdemo, sporocimo, da ga nismo nasli.
     Funkcija BeriElement bi lahko npr. vrnila Boolean,
     ki pove, ce je kljuc nasla ali ne.

Brisanje elementa z danim kljucem gre spet na zelo podoben nacin.  Iz kljuca
izracunajmo pripadajoci indeks i, nato pa se sprehodimo po seznamu, na
katerega kaze T[i].  Ce najdemo zapis s pravim kljucem, ga moramo zbrisati
iz seznama.  Ce takega kljuca ne najdemo, ga pac ni bilo v nasi tabeli.

Ko hash tabelo nehamo uporabljati, se seveda spodobi, da pocistimo zasedeni
pomnilnik -- sprehoditi se moramo po indeksih od 0 do N-1 in pri vsakem
zbrisati vse zapise v seznamu T[i].  Ce se gremo objektno programiranje, bi
to opravili v destruktorju.

Vcasih moramo kaksno operacijo narediti na vseh kljucih v hash tabeli.  V
tem primeru lahko naredimo zanko po indeksu i in pri vsakem indeksu se zanko
po seznamu, ki se pricne pri T[i].  [Ce nas skrbi, da je kljucev v tabeli
veliko manj kot indeksov in bomo zato porabili veliko casa za sprehajanje po
indeksih, pri katerih je T[i] prazen seznam, lahko seveda vse zapise iz vseh
seznamov tudi povezemo v en velik seznam in imamo v neki posebni
spremenljivki kazalec na zacetek tega seznama.  Ta seznam bi moral biti
najbrz tudi dvosmerno povezan (ce bi hoteli podpreti brisanje poljubnega
kljuca iz hash tabele).  V praksi je zelo vprasljivo, ce bi se vse to
dodatno knjigovodstvo in kompliciranje res izplacalo.]

--

V gornjem primeru je zaradi preprostosti hash tabela kar v neki globalni
spremenljivki.  Vcasih je to dovolj; v splosnem pa je seveda bolje, ce hash
tabelo napisemo kot razred (ali kot record + nekaj podprogramov), tako da
imamo lahko vec hash tabel naenkrat.  Kdor dela v C++, si lahko pomaga tudi
s templati, da enostavneje podpre razlicne tipe kljucev in vrednosti.  Res
pa je, da pri nalogah na tekmovanjih take splosnosti obicajno ne
potrebujemo.

Seveda lahko, ko resujemo nek konkreten problem, te reci prilagodimo svojemu
problemu.  Ce na primer vemo, da ne bomo nikoli poskusali po veckrat dodati
istega kljuca v tabelo, se podprogramu za dodajanje kljuca ni treba
sprehajati po seznamih, da bi preveril, ce kljuc ni ze prisoten.  Vcasih
tudi ne potrebujemo brisanja kljucev (razen na koncu, ko bomo pobrisali
vse).

--

Vcasih je koristno tudi, da bi tabelo med delom povecali, ce npr. nimamo
vnaprej obcutka za to, koliko elementov bomo zeleli hraniti v njej, pa nas
skrbi, da ne bi po mnogih dodajanjih postali seznami ze predolgi.  Recimo
torej, da imamo tabelo T z N elementi in smo kljuce preslikali v indekse
0..N-1 z neko funkcijo h.  Zdaj alociramo novo tabelo U z M elementi (za nek
M > N) in pripravimo novo funkcijo h2, ki bo preslikovala elemente v indekse
0..M-1.  Potem ni treba drugega, kot da se sprehodimo po vseh seznamih nase
stare tabele in elemente premikamo v sezname nove tabele:

  inicializiraj tabelo U;
  for i := 0 to N-1 do
    P := T[i];
    while P <> nil do
      NoviIndeks := h2(P^.K);
      Naslednji := P^.Naslednji;
      P^.Naslednji := U[NoviIndeks];
      U[NoviIndeks] := P;
      P := Naslednji;
    end
  end
  zbrisi tabelo T;

Taki operaciji pravijo pogosto "rehash".  Vidimo, da nam ni bilo treba
alocirati novih zapisov, ampak smo obstojece zapise le prevezali oz.
prerazporedili iz T-jevih seznamov v U-jeve sezname.

--

Zdaj je pa ze skrajni cas, da recemo tudi kaksno o funkciji h, ki naj bi nam
preslikovala kljuce v indekse 0..N-1.  Tej funkciji pravimo razprsevalna
funkcija oz. po domace kar hash funkcija.  Pogosto je zasnovana tako, da
najprej preslika kljuc v neko poljubno pozitivno celo stevilo, potem pa
vzame ostanek pri deljenju tega stevila z N.  Tako lahko s prakticno isto
funkcijo brez tezav podpremo razlicne N-je.

Nekateri delajo iz hash funkcij celo znanost, vendar so za vsakdanje potrebe
tudi bolj vsakdanje hash funkcije cisto dobre.

Nekaj primerov hash funkcij za nize: naj bo S recimo niz dolzine L, njegovi
znaki pa naj bodo S[1]...S[L].  Vsak znak si lahko zamisljamo kot stevilo
med 0 in 255 (oz. ga v to stevilko pretvorimo) [ali pa do 65535, ce se gremo
unicode ali kaj podobnega :)].

  h(S) := (S[1] + ... + S[L]) mod N

Ta ni prevec dobra, ker lahko pokrije razmeroma malo moznih indeksov.
Recimo, da so nasi nizi dolgi do 20 znakov; potem tista vsota gotovo ni
vecja od 255 * 20 = 5100, torej (ne glede na N) ne bomo dobili vec kot
toliko razlicnih indeksov.  Ce je N vecji, bomo izkoristili le prvih 5100
celic nase tabele!

  h(S) := (1*S[1] + 2*S[2] + ... + L*S[L]) mod N

Ta je ze precej boljsa in je za marsikatere potrebe cisto dovolj dobra.

  h := 0;
  for i := 1 to L
    h := h shl 3 + S[i]
  vrni (h mod N);

Ta bi znala biti se boljsa.  Paziti je treba na moznost, da lahko vrednost
h-ja postane vecja od 2^31 -- ce imamo h deklariran kot predznaceno, ne pa
nepredznaceno vrednost, je lahko celo negativen in potem tudi mod obicajno
vrne negativno vrednost.  Na to bi morali paziti, ker bi drugace obcasno
dobili negativne indekse in bi se nam program sesuval.

  h := 0;
  for i := 1 to L
    h := (h * 256 + S[i]) mod N
  vrni h;

Ta je se bolj ortodoksna -- vidimo, da S v bistvu obravnava kot velikansko
pozitivno celo stevilo, zapisano v 256-iskem stevilskem sistemu, in izracuna
ostanek pri deljenju tega velikanskega stevila z N.  (V bistvu posnema "pes
deljenje", ce se ga se kaj spomnite, le da si zapomni le ostanek, kolicnika
pa ne.)  Slabost te funkcije je, da mora veliko mnoziti in deliti, zato
porabimo vec casa, da jo izracunamo -- ze res, da kljuce zato mogoce malo
bolje razprsi, vendar ponavadi ta izboljsava ni toliksna, da bi odtehtala
njeno pocasnost, zato se ji je bolje izogibati.

Vidimo, da si te funkcije, se posebej zadnji dve, prizadevajo, da bi nekako
razmazali informacije iz kljuca po vseh bitih h-ja, nato pa z operacijo "mod
N" dobili iz tega primeren indeks.  To pa je tudi razlog, zakaj si pri hash
tabelah radi prizadevamo, da N ne bi bil recimo potenca stevila 2, ker bi
tako "mod N" v bistvu vzel le spodnjih nekaj bitov, ostalo pa ignoriral.
Dostikrat radi za N vzamejo celo kaksno prastevilo.

--

Ce bi kot kljuce uporabili kaksna velika cela stevila, bi lahko za hash
funkcijo vzeli kar operacijo "mod N" (in spet mogoce pazili na negativna
stevila, ce so lahko kljuci tudi taki).  Ce so nasi kljuci kaksne strukture,
pari vrednosti ali kaj podobnega, bi lahko izracunali neko hash funkcijo za
vsako komponento posebej in jih potem skombinirali z xor ali cim podobnim.

--

Povzetek: hash tabela je podatkovna struktura, ki nam omogoca hraniti
mnozico kljucev, ob vsakem kljucu pa se neko pripadajoco vrednost, in (ce se
kljuci lepo razprsijo med indekse) do kljucev lahko pridemo v casu, ki je
bolj ali manj konstanten oz. enak za vse kljuce.  Lepo pri hash tabeli je,
da lahko za kljuce uporabimo vec ali manj karkoli, ce le imamo primerno hash
funkcijo zanje.

Upam, da s tem dolgim mailom nisem prevec zamoril in da sem stvari kolikor
toliko razumljivo razlozil.  Nikar si ne pomisljajte vprasati, ce vam kaj ni
jasno -- zgoraj opisane stvari naj ne bi bile prevec zapletene in ideja je,
da bi jih razumeli vsi.  Ce niste prevec sramezljivi, lahko posljete
vprasanje tudi na mailing listo, saj bo odgovor mogoce koristil se komu.

Vsakomur, ki so mu hash tabele nekaj novega, bi priporocil, da poskusa hash
tabelo implementirati, se s tem malo poigrati in mogoce resiti kaksno
nalogo, pri kateri si lahko pomagate s hash tabelo.

--

Na tistem strezniku v Valladolidu sem naletel se na nekaj nalog, pri katerih
pride hash tabela prav:

- naloga 409: http://acm.uva.es/p/v4/409.html
  To je cisto solski primer uporabe hash tabele.

- 425: http://acm.uva.es/p/v4/425.html
  Tu hash tabela se ni cela resitev, je pa
  lahko pomemben del resitve.

Tole je ena od nalog z lanske olimpijade, pri kateri hash tabela tudi pride
zelo prav:

http://olympiads.win.tue.nl/ioi/ioi2001/contest/day2/double/double.pdf

Pravzaprav je ta naloga po svoje precej podobna nalogi 386, ki sem jo poslal
pred nekaj dnevi.  Kot sem omenil ze zgoraj, pride tudi pri nalogi 386 hash
tabela (+ malo zvitosti) zelo prav.  Ce vanjo shranite nekatere delne
rezultate, vam jih ne bo treba racunati po veckrat in lahko s tem prihranite
veliko casa.  To se se posebej pozna, ce bi radi poiskali cetverice za a <=
1000 in ne le za a <= 200.